【内存分配与管理】RAII与智能指针
在上一篇文章中,我们探讨了C++为何选择值语义作为其核心设计哲学,以及这种选择如何深刻影响了C++的内存模型。我们看到了值语义带来的高效性,但也意识到并非所有对象都能简单地“被复制”——线程、文件、网络连接等资源天然具有唯一性,必须通过引用语义来管理。
这引出了一个更深层的问题:当C++程序员不得不使用引用语义(堆上对象)时,如何避免值语义所不具备的那些内存管理复杂性——内存泄漏、悬空指针、双重释放?
答案就在C++另一项核心思想中:RAII(Resource Acquisition Is Initialization)。
本文将探讨RAII如何将资源生命周期绑定到对象生命周期,以及智能指针如何通过明确的所有权模型,让堆上资源也能享受到类栈的自动管理。
RAII思想
为什么需要RAII?
在没有RAII的情况下,有以下问题:
资源所有权不清晰,很容易出现资源泄露或double free的问题,比如类中有裸指针成员
资源释放路径不唯一,函数退出的方式太多,比如return、分支内提前return、抛异常等等,在代码复杂的情况下,无法完全保证每条路径都正确地释放资源,可能没释放,也可能多次释放
存在异常安全问题。如果程序抛异常,后续代码没法执行到,导致资源未释放。虽然程序会栈展开,会调用对象的析构函数,但是析构函数内并没有将资源释放
void f() { int*p = new int{3}; int error = doSomething(p); if (error) { delete p; //释放内存,当出现错误的时候 return; } finalize(p); delete p; }
RAII如何解决该问题?
RAII(Resources acquisition is initialization),意为资源获取即通过构造函数初始化,并通过析构函数释放资源。其核心思想是把“资源的生命周期”绑定到“对象的生命周期”,并借助析构函数的必然执行,来保证资源一定被释放。而其具体实践unique_ptr/shared_ptr还起到了明确资源所有权的作用,解决资源生命周期由谁管理的问题。
比如,将资源绑定到一个局部变量上,RAII将“资源对象的生命周期问题”转换为了”栈对象生命周期问题“。其之所以保证资源不泄露,核心是不再依赖于程序员手动控制,而是依赖于语言的两个机制——一是作用域结束一定调用对象的析构函数,编译器会在每个“可能离开作用域的点”插入析构逻辑,比如函数结尾、return等,即解决了“资源释放路径不唯一”的问题,统一由编译器完成;二是异常会触发栈展开,编译器会维护一张“清理表”,每构造一个对象就注册一个析构动作,并在离开作用域时倒序执行,调用析构函数,释放资源,即解决了“异常安全问题”
怎么体现的资源所有权归属?具体看下一节unique_ptr中的内容
那RAII一定可以保证资源不泄露吗?
不一定。假如程序没有正常结束,比如std::terminate()、kill -9杀死进行,析构函数根本不会执行,就会导致RAII失效。或者,如果析构函数本身有问题,比如析构函数可能抛出异常、资源释放不完整等等
void f() {
Wrapper w;
std::terminate(); // 或 std::abort()
}
RAII的缺点有哪些?
RAII只是基于对象生命周期对资源进行管理,只关心对象何时离开作用域,其不会分析资源的所有权关系,可能导致资源被提前释放或循环引用问题。相比之下,Java/C#下的GC式管理则从“根”触出发,遍历整个对象图,找出“不可达”对象,其保证真正没人能访问的资源才被释放。但是,GC存在运行时开销,且资源释放具有不确定性,可能导致程序运行不稳定性,而RAII则不存在这些问题,其性能更优
智能指针
RAII是指导思想,而智能指针是一种具体的实现方式。
unique_ptr
unique_ptr会独占资源的所有权,即两个unique_ptr不能同时指向同一个资源对象。该唯一所有权通过”不可拷贝,只可通过移动转移资源所有权“来体现出来,其底层将拷贝构造函数和赋值运算符重载 = delete删除
std::unique_ptr<A> a1(new A());
std::unique_ptr<A> a2 = a1 //编译报错 不允许复制
std::unique_ptr<A> a3 = std::move(a1) //转移资源所有权 a1不再持有资源对象
使用方式:
通过->调用指针原有的方法,而. 则表示调用智能指针本身的方法:
get():获取原生指针,不推荐使用
bool():判断是否拥有原生指针
release():失去资源对象所有权,返回原生指针,但不销毁原生指针
reset():释放资源、销毁指针。如果参数为一个新指针,则将继续管理该新指针
样例:
std::unique_ptr<A> a1(new A());
A* origin = a1.get();
if(a1)
{
}
std::unique_ptr a2(a1.release()); //转移资源所有权
a2.reset(new A()); //释放资源 销毁指针 并重新管理新资源
a2.reset(); //释放资源 销毁指针
a2 = nullptr;
站在和shared_ptr对比的视角来看,其优点:
- 零额外开销,性能接近裸指针
实际开发中,优先使用unique_ptr,只有在确实需要共享资源时再使用unique_ptr。
站在RAII目标的视角来看,其优点:
明确了资源的所有权,生命周期易管理,大大降低资源泄露的可能性,这是智能指针最具价值的一点
把“资源的生命周期”绑定到“对象的生命周期”,在对象析构时自动释放资源,避免了忘记delete这种错误。此外,搭配上异常的栈展开机制,更好地保证异常安全
对于第一点:unique_ptr最大价值在于对资源所有权的掌控,大大降低了内存泄露的风险,并增强了代码的可维护性和可读性。
比如,在使用裸指针的代码中,你看到一个函数签名 voidProcess(MyObj* obj)时,你完全没法判断这个函数会完全接管obj并负责删除释放它,还是只是读取/修改资源,很容易导致资源泄露或是double free。而使用unique_ptr做参数——void process(std::unique_ptr< MyClass> obj)可以明确地表示,当前函数要接管当前资源的所有权,而调用者则必须使用std::move将资源所有权转让,不需要再操心该资源的生命周期,而是完全由函数内部控制。所以,unique_ptr将模糊的注释/约定提升为了由编译器强制检查的一部分,大大提升了代码的可维护性,减少了资源泄露的可能性(代码语义不明确,同样是导致容易出现资源泄露的源头)。
再比如,一个类持有一个裸指针,你怎么防止出现空悬指针?怎么知道在释放其指向的资源时,没有其他指针依然在引用该资源?而使用unique_ptr可以非常明确地知道,该资源只属于该对象,不会被其他对象共享,从而保证资源的正确释放
class TextureManager
{
std::unique_ptr<Texture> textures;
};
那是不是说,裸指针没用了呢?
智能指针并不是裸指针的完美替代品,应该只在需要转移资源所有权时才使用智能指针。 具体来说,通过unique_ptr表达资源所有权的转移(onwership),通过引用/裸指针表达借用(borrowing)。比如,工厂函数创建对象时,显然应该转移资源的所有权:
std::unique_ptr<Widget> createWidget() {
return std::make_unique<Widget>();
}
而对于一些只读处理,或只是修改对象的状态,则应该用裸指针。这一点,已经在上一篇文章中提到过了。
shared_ptr
shared_ptr强调共享资源所有权,多个shared_ptr可以拥有同一个原生指针。其内部通过引用计数的方式来管理,在引用计数为0时才会释放资源。
常用方法:
get():获取原生指针,不推荐使用
bool():判断是否拥有原生指针
reset():释放资源、销毁指针。如果参数为一个新指针,则将继续管理该新指针
unique():如果引用计数为1,则返回true,否则返回false
use_count():返回引用计数的大小
应用场景:
一份资源需要被多个对象管理时,比如多个任务需要同一份资源,并且在任务结束时,由于无法确定哪个任务先结束,所以无法确定哪个任务会最后释放该资源,为了避免提前释放或遗漏释放,此时可以通过shared_ptr对该资源进行管理。这个任务可能是线程、回调或是协程
缺点:
开销更大。一方面是需要额外的内存开销,用于存储指向控制块的指针,控制块中会存储引用计数、weak_count、删除器等;另一方面是性能开销更大,在多线程环境下,对引用计数的加减操作都是原子的,比裸指针、unique_ptr慢得多,此外,在析构时涉及到一次虚函数的调用(类型擦除)

相比于unique_ptr,其资源所有权不明确。这是从设计层面出发的,由于多个shared_ptr都共享资源的所有权,所以资源的生命周期不好管理,容易隐式地延长对象的生命周期(在make_share初始化的情况下更明显),不好调试。
底层原理:简化的结构:shared_ptr本身拥有一个指向资源对象的指针和一个指向控制块的指针,控制块中有强引用计数、弱引用计数、删除器以及指向资源对象的指针。
class shared_ptr {
T* ptr;
ControlBlock* ctrl;
};
struct ControlBlock {
std::atomic<long> shared_count; // 强引用计数
std::atomic<long> weak_count; // 弱引用计数
void (*deleter)(void*); // 删除器
void* ptr; // 指向对象
// 也可能有 allocator、类型擦除等
};
其中,对于引用计数的修改通过std::atomic来保证是原子的,可以保证线程安全,避免多线程下拷贝/析构时的的计数错误。 比如线程A和线程B同时读取到引用计数准备+1,最终只加上了一次,进而导致资源被提前释放
之所以还要有弱引用计数,是因为需要通过弱引用计数来管理“控制块本身”的生命周期,防止weak_ptr访问控制块时访问到已释放的内容,出现未定义行为。 具体来说,当shared_ptr全部释放,强引用计数归零,资源对象被释放后,weak_ptr是不知道的,需要通过lock()或expired()访问控制块,检查shared_count是否大于0。总结一下,强引用计数控制资源对象的生命周期,弱引用计数控制控制块的生命周期
shared_ptr是否是线程安全的?
shared_ptr只有引用计数是线程安全的,允许在多线程的情况下同时拷贝/析构shared_ptr,但是操作shared_ptr指向的对象,比如两个线程同时修改资源对象中的值;此外,修改shared_ptr本身并不是线程安全的,比如多线程同时修改shared_ptr资源指针的指向。
//对资源对象进行写操作
std::shared_ptr<int> p = std::make_shared<int>(0);
// 线程1
(*p)++;
// 线程2
(*p)++;
//对shared_ptr本身
std::shared_ptr<T> p;
// 线程1
p = std::make_shared<T>();
// 线程2
p.reset();
之所以不保证线程安全,是出于性能考虑的,shared_ptr本意是要解决资源管理问题,而不是并发访问问题,线程安全需要由用户自己控制,比如加锁或者使用原子类型
weak_ptr
weak_ptr不拥有资源的所有权,指向shared_ptr所管理的资源对象,但不影响资源对象的生命周期,只会增加弱引用计数。主要应用于:不拥有资源所有权,又需要安全访问资源对象的场景。
比如在观察者模式中,存在“事件源”和”观察者“的关系,如果观察者被销毁,但事件源扔持有观察者的强引用,就会导致悬空指针,此时就需要通过weak_ptr安全地检查观察者是否存在;
在缓存系统中,可以使用weak_ptr实现“弱引用”缓存,当缓存项被其他强引用持有时,其不会被立即清除,提高重复获取效率,但如果没有其他强引用,则缓存失效,可及时释放资源
在异步执行任务时,在执行的一系列Task时,我们还需要观察这些TAsk的执行状态,比如放在一个容器中观察,但是该容器不应该拥有Task本身,Task的生命周期不应该被一个观察者所改变,所以容器中应该使用weak_ptr来安全地访问Task对象
常用方法:
创建:必须依赖于shared_ptr
expired():判断所指向的原生指针是否被释放,如果被释放则返回true,否则返回false
use_count():返回原生指针的引用计数
lock():由于weak_ptr没有资源所有权,所以没有重载*和->,只能通过lock()获取到当前资源对应的shared_ptr。如果weak_ptr管理的资源没被释放,则返回一个shared_ptr,通过该shared_ptr进行资源管理,同时引用计数+1;如果被释放了,则返回nullptr
reset():将本身指向置空
样例:
std::shared_ptr<A> a1(new A());
std::weak_ptr<A> weak_a1 = a1;//不增加引用计数
if(weak_a1.expired())
{
//如果为true,weak_a1对应的原生指针已经被释放了
}
long a1_use_count = weak_a1.use_count();//引用计数数量
if(std::shared_ptr<A> shared_a = weak_a1.lock())
{
//此时可以通过shared_a进行原生指针的方法调用
}
weak_a1.reset();//将weak_a1置空
为什么weak_ptr要有lock()操作? 因为weak_ptr不拥有资源对象,不能保证资源对象的存货。如果先用expired()方法判断再访问,再多线程环境下会产生竞态条件。具体来说,比如线程A通过expired()判断资源依然有效,但是线程B反手就把资源释放了,此时再切换回线程A,再继续访问已释放的对象,即产生未定义行为。核心问题是,检查和使用不是原子操作。 而lock()则提供了一个原子操作:在对象仍然存在时安全地将 weak_ptr 转换为 shared_ptr,不存在时shared_ptr为空,从而保证访问安全。
if (!wp.expired()) { //错误的做法!!!
wp.lock()->DoSomething();
}
auto sp = wp.lock(); //正确的做法!
if (sp) {
sp->DoSomething();
}
为什么可以解决循环引用的问题?
weak_ptr并不参与资源所有权的管理,不会增加强引用计数,从而不会形成强引用的闭环。weak_ptr将整个循环结构中的一条强引用改为了弱引用,打破了shared_ptr之间互相拥有的关系
具体来说:【weak_ptr和shared_ptr的交互工作原理】shared_ptr与 weak_ptr 通过共享同一个控制块协同工作:控制块中维护强引用计数和弱引用计数。shared_ptr 负责对象的所有权管理,每次拷贝增加强引用计数,析构减少强引用计数,当强引用计数归零时销毁资源对象,但保留控制块,以防weak_ptr访问空对象;weak_ptr 仅作为观察者,拷贝时只增加弱引用计数,不影响对象生命周期。当需要访问对象时,weak_ptr 通过 lock()原子地检查对象是否仍存在,并在存在时提升为 shared_ptr(增加强引用计数),从而实现安全访问;当强弱引用计数都归零时,控制块才被释放。
weak_ptr的应用场景:
对于类似“shared_ptr但有可能空悬的指针”使用weak_ptr。
比如实现缓存:假设有一个Widget类,我们需要从磁盘上加载该对象,这个过程涉及到IO,比较耗时。为了避免每次使用到该资源都进行IO,所以希望Widget对象可以缓存起来,如果Widget对象还存在于内存中在被使用着,则不需要重新加载;在Widget对象不再被使用时,缓存自动失效,不阻止对象被销毁,避免内存泄露。
std::shared_ptr<Widget> fastLoadWidget(int id) {
static std::unordered_map<int, std::weak_ptr<Widget>> cache;
auto objPtr = cache[id].lock();
if (!objPtr) {
objPtr = loadWidgetFromFile(id);
cache[id] = objPtr; // use std::shared_ptr to construct std::weak_ptr
}
return objPtr;
}
之所以unordered_map不直接存储shared_ptr,是因为这会导致缓存中的对象永远不会被销毁并释放,除非手动检查引用计数并清理。而使用weak_ptr,可以在资源对象不被shared_ptr引用时,自动被释放。
此外,使用weak_ptr还可以避免裸指针的悬空引用问题,在通过lock()尝试获取缓存的资源时,如果资源已经被释放了,则会返回nullptr
观察者列表:
比如观察者模式中,被观察者持有指向观察者的指针,以在发生变化时可以通知到观察者。被观察者虽然不关心观察者的生命周期,但是如果观察者析构后,需要保证不会再去访问它。 这种场景下,合理的设计为,被观察者持有一个容器来存储指向观察者的weak_ptr,并在通知观察者前,先就检查是否悬空
循环引用:
比如双向链表的场景下,通过shared_ptr来管理节点资源,会因为循环引用而导致资源泄漏。比如,右侧节点资源的释放,需要等待左侧节点资源释放,让next指针置空,而左侧节点资源的释放,需要等待右侧节点资源释放,让prev指针置空。
struct ListNode
{
int _data;
std::shared_ptr<Listnode> _next;
std::shared_ptr<ListNode> _prev;
};

通过weak_ptr解决:weak_ptr引用资源时,不会增加引用了同一个资源的shared_ptr的引用计数,_next和_prev不参与资源释放管理逻辑,打破循环引用
struct ListNode
{
int _data;
std::shared_ptr<Listnode> _next;
std::shared_ptr<ListNode> _prev;
};
智能指针的使用方式
make_shared:
总的来说,绝大多数情况下使用make_shared来构造智能指针对象,其通过可变参数模板+完美转发将一个任意实参集合转发给动态分配内存的对象的构造函数,将对象和控制块合并为一次内存分配,从而减少分配开销,增强局部缓存性,并增强异常安全,但是代价是对象和控制块的生命周期绑定在了一起,可能导致内存延迟释放,不推荐对大对象使用make_shared构造
具体来说,make_shared的优点在于:
一是因为其将“对象+控制块”合并为一次内存分配,而通过new的方式则是两次内存分配,new/malloc是昂贵的操作,可能导致系统调用、锁竞争(多线程),且一次内存分配不易产生内存碎片。
二是因为“对象+控制块”二者存储在一块连续的内存中,在访问对象时,大概率会顺便将控制块一起加载到cache line中,缓存局部性更好。
make_shared: [控制块 + 对象] 连续 普通写法: [控制块] [对象] (分散)三是因为make_shared是异常安全的。比如下面的场景,在C++17之前,函数参数的计算顺序是不固定的,有可能是new int(10)->bar()->构造shared_ptr。如果bar()抛异常,会导致shared_ptr没有构造成功,进而内存泄露,因为原生指针没有被管理起来。C++17之后不存在该问题,虽然顺序不固定,但是禁止交错,每个参数表达式的完整求值,必须在另一个开始求值之前完成
//错误做法 foo(std::shared_ptr<int>(new int(10)), bar());而mask_shared要么完全失败,要么完全成功,没有中间状态,其是一次性分配内存,并且如果任何一步出现异常,都会自动释放申请的所有资源
foo(make_shared<int>(10), bar());但如果要自定义删除器,正确的方法是将new语句单独提出来,保证new构造对象和通过智能指针托管资源这两件事一起做完,不会被异常打断
//正确做法:保证异常安全 shared_ptr<int> sp(new int(10), myDel); foo(sp, bar());
make_shared的缺点在于:
不提供自定义删除器。 这是因为资源对象“内嵌”在控制块中,而不是像非make_shared构造时为两块独立的内存块,所以没法只对一整块内存中的一小部分释放,释放时必须整体释放这块内存。 进而不能将释放策略交给用户,用户并不清楚如何完整地释放内存块。(allocate_shared同理)
由于控制块和对象是绑定的,所以只要控制块还活着,对象本地占用的内存就不会释放(注意区分是对象本地占用的内存,还是对象管理的资源,比如vector本地占用的内存并不大,但是其管理的资源可能很大,其管理的资源会在其析构函数中被释放)。比如在waek_ptr延长内存引用的情况下,可能导致一整块内存无法释放,所以对于本地内存占用较大的对象,更推荐使用new的方式初始化智能指针:
// 如果需要 weak_ptr 长期存在但想及时释放大对象 auto sp = std::shared_ptr<BigObject>(new BigObject()); std::weak_ptr<BigObject> wp = sp; sp.reset(); // BigObject析构,控制块还在,但对象内存立即释放 // 内存占用:只有小的控制块,没有BigObject的大内存make_shared/allocate_shared会绕过类自定义的operator new/delete,因为其会直接通过全局通用版本的operator new/delete分配一块同时包含控制块和资源对象的内存,而不是通过类自身的内存分配/释放逻辑
支持{}列表初始化不方便:无法直接支持{}初始化列表来进行初始化,make_shared中使用的是()初始化。如果非要使用{}初始化,调用initializer_list构造函数,则可以先创建一个initializer_list对象再传给make_shared:
//创建initializer_list对象 auto initList = {10, 20}; //调用到vector中initializer_list参数类型的构造函数 auto sp = make_shared<vector<int>>(initList);
自定义删除器:
我们可以指定自定义的删除器在释放资源时执行一些特殊的操作,比如资源释放时打印日志;管理除了内存以外的资源,比如FILE*、mutex等;或与自定义分配器(allocator) 相互配合使用。删除器本质上是一个可以调用的对象,比如函数指针、lambda表达式、仿函数等。 默认是通过delete/delete[],后者是通过模板特化来实现的。而删除器又分为有状态删除器和无状态删除器。无状态删除器内部不保存任何数据成员,其大小为0(空类优化),不需要额外的存储开销,比如没有捕获任何变量的lambda表达式:
auto deleter = [](int* p) {
delete p;
};
struct Deleter {
void operator()(int* p) const {
delete p;
}
};
有状态删除器内部会持有额外的数据,每个删除器的实例可能不同,这会增加智能指针的大小:
int x = 42;
auto deleter = [x](int* p) {
std::cout << x;
delete p;
};
struct Deleter {
int id;
Deleter(int i) : id(i) {}
void operator()(int* p) const {
std::cout << "delete by " << id << "\n";
delete p;
}
};
那删除器的类型对智能指针有什么影响吗?
unique_ptr是通过模板推导的方式实例化对应的删除器,删除器属于unique_ptr类型的一部分,会在类中直接保存删除器本身。
std::unique_ptr<T, Deleter> p(new T, deleter);
template<class T, class Deleter>
class unique_ptr {
T* ptr;
Deleter deleter;
};
之所以这样设计:
零额外开销(理论):如果删除器是无状态的,那么编译器可以用空基类优化,unique_ptr相比于裸指针没有任何额外内存成本。不过,由于unique_ptr是非平凡类型,函数传参的过程中没法放到寄存器中,需要通过栈,所以会有些许额外的开销
不需要共享:unique_ptr独占资源所有权,不需要控制块,所以不需要跨对象共享删除器,所以可以放心地把删除器放在对象中
但缺点是,删除器如果有状态,会直接影响到unique_ptr本身的大小
对于shared_ptr来说,删除器存储在控制块中,shared_ptr通过指针引用控制块,删除器并不属于shared_ptr类型的一部分
std::shared_ptr<T> p(new T, deleter);
class shared_ptr {
T* ptr;
ControlBlock* ctrl;
};
struct ControlBlock {
int shared_count;
int weak_count;
void (*deleter)(T*); // 实际更复杂(类型擦除)
};
之所以这样设计,是因为:
shared_ptr需要进行类型擦除,让”带不同deleter构造出来的shared_ptr“在类型系统看来是同一种类型,以支持多个shared_ptr对同一资源进行管理如果没有类型擦除:
auto p1 = shared_ptr<int, D1>(...); auto p2 = p1; // OK shared_ptr<int, D2> p3 = p1; // ❌ 无法转换有类型擦除:尽管删除器类型不同,但是允许重新赋值,共享管理一块资源
auto p1 = std::shared_ptr<int>(new int, D1{}); std::shared_ptr<int> p2 = p1; // ✅ OK多个shared_ptr必须共享同一个删除器,如果不放在堆上的控制块中,让每个shared_ptr都拥有自己的专属的删除器,就无法保证读个shared_ptr在对象销毁时行为是一致的
其缺点是,有虚函数调用+额外的控制块
operator bool类型转换:
shared_ptr和unique_ptr都支持了operator bool的类型转换,如果智能指针对象没有管理资源,则返回false,否则返回true。
if(ptr) //合法判断
智能指针unique_ptr作为参数传递:
按值传递callee(unique_ptr< Widget> smart_w): 需要通过std::move()转移资源所有权。这是最常见的使用方式,代表着要转移资源的所有权
按引用传递callee(unique_ptr< Widget> &smart_w): 基本不太可能出现。因为按引用传递有两方面优势,一方面是避免拷贝,另一方面是希望函数内修改对象,并在函数返回后,调用者可以看到这个改动的效果。但是,对于unique_ptr而言,不存在拷贝一说,只有移动赋值,且代价较小;而在函数内部,修改unique_ptr中的裸指针的这个行为几乎不存在(如果修改的是资源对象本身,则更不需要传引用了)。所以,参数引用类型并不常见,其完全可以通过一个裸指针来传递
void caller()
{
unique_ptr<Widget> smart_w = std::make_unique<Widget>();
callee1(smart_w.get());
callee2(smart_w.get());
// 可以继续用smart_w,也可以随便抛出异常
} // guarantee: 当caller退出后,smart_w会销毁Widget资源,不管发生任何异常
void callee1(Widget *w)
{
// use w,同时,可以随便抛出异常
}
void callee2(Widget *w)
{
// use w,同时,可以随便抛出异常
}callee(const unique_ptr<Widget> &smart_w)callee(const unique_ptr<Widget> &smart_w)
按常引用传递callee(const unique_ptr< Widget> &smart_w): 同样的,意义不大,完全可以用裸指针替代,比如const Widget*或const Widget&
按右值引用传递callee(unique_ptr< Widget> &&smart_w): 对于unique_ptr来说,几乎和按值传递等效
智能指针常见错误
将一个原生指针托管给多个智能指针管理。 无论是unique_ptr还是shared_ptr都不应该这样做,对于shared_ptr而言,会对同一个资源对象创建多个控制块,而多个控制块则意味着多重的引用计数,而多重的引用计数则意味着多重析构
void incorrect_smart_pointer()
{
A *a= new A();
std::unique_ptr<A> unique_ptr_a1(a);
std::unique_ptr<A> unique_ptr_a2(a);// 此处将导致对象的二次释放
std::shared_ptr<A> shared_ptr_a1(a);
std::shared_ptr<A> shared_ptr_a2(a);// 此处将导致对象的二次释放
}
为了避免此类问题,我们应该尽可能避免用裸指针构造智能指针,正确的方式是使用make_shared/make_unqiue,如果要求自定义删除器的话,则通过:
std::shared_ptr<A> shared_ptr_a1(new A(), MyDel);
接着,如果需要共享资源,则通过拷贝构造的方式作为新shared_ptr的创建蓝图
将this指针直接托管给shared_ptr(本质上还是问题一),会导致二次释放。 因为新的shared_ptr在创建时,会创建新的控制块,而新的控制块则意味着新的引用计数,如果有旧的shared_ptr同样也在管理当前对象,但是引用计数却没有共享。这将导致其中一个shared_ptr计数为0后提前释放资源对象,而另一个shared_ptr后续再操作该对象时出现异常
class E
{
std::shared_ptr<E> use_this()
{
//错误方式,用this指针重新构造shared_ptr,将导致二次释放当前对象
return std::shared_ptr<E> this_shared_ptr1(this);
}
};
std::shared_ptr<E> e = std::make_shared<E>();
正确的方式是,如果想在类内部获取一个管理当前对象的shared_ptr指针,应该让该类继承enable_shared_from_this,并使用C++提供的shared_from_this()来获取到shared_ptr,该shared_ptr并不持有新的控制块,而是和旧的shared_ptr共享同一个控制块,从而避免多次释放的错误。但如果旧的shared_ptr不存在,即当前资源对象并没有与之关联的控制块,则shared_form_this()会抛出异常
class A : public std::enable_shared_from_this<A> {
public:
std::shared_ptr<A> get_shared_ptr() {
return shared_from_this();
}
};
其原理是,会在 enable_shared_from_this 类中维护一个weak_ptr,shared_from_this会将该weak_ptr作为参数传入shared_ptr的构造函数,返回一个shared_ptr对象
将栈上对象托管给智能指针,会导致二次销毁。栈上对象在出了作用域后本来就会被自动销毁,用不到智能指针
总结
通过对RAII和智能指针的深入分析,我们看到C++如何通过所有权模型来驯服引用语义的复杂性:
unique_ptr明确了独占所有权——“这个资源只属于我,我负责它的生死”
shared_ptr明确了共享所有权——“我们共同拥有这个资源,最后一个离开的负责关灯”
weak_ptr明确了观察者角色——“我只是看看,不负责管理”
这种所有权模型不仅解决了资源泄漏问题,更在代码层面显式表达了设计意图,将原本靠注释和约定的资源管理规则,提升为编译器可以检查和强制执行的类型系统特性。
然而,智能指针只是解决了“堆上资源”的管理问题。对于值语义对象本身,C++还有另一个重要的优化方向:避免不必要的拷贝。 这就引出了我们下一篇文章的核心话题:移动语义与拷贝优化。