智能指针详解
裸指针
指针是一种强大的工具,但裸指针的使用通常伴随着一些问题:它的声明并未指明指向的是单个对象还是一个数组;通常无法判断指针是否悬垂;并且需要手动管理内存的生命周期。
在传统 C++ 中,new 触发堆内存分配后必须手动
delete
释放:未释放会造成内存泄漏,重复释放可能引发程序崩溃或未定义行为。
智能指针
C++11 引入了智能指针(smart pointer)。智能指针利用 RAII(Resource
Acquisition Is
Initialization,资源获取即初始化)技术,将动态分配的裸指针封装在栈上的对象中。当智能指针对象超出作用域析构时,会自动调用
delete 释放内存,从而彻底规避了手动内存管理的风险。
C++11 共有四种智能指针,全部位于 <memory>
头文件中,分别是:std::auto_ptr、std::unique_ptr、std::shared_ptr、std::weak_ptr。其中
std::auto_ptr 是 C++98 遗留的失败设计,被
std::unique_ptr 全面替代,并在 C++17 中被移除。
std::unique_ptr
std::unique_ptr
是独占所有权的智能指针。默认条件下(使用默认删除器)std::unique_ptr
具有和裸指针相同的大小;使用空类型(无状态仿函数或
lambda)的自定义删除器时,通过 EBO(Empty Base
Optimization,空基类优化)与内联优化,同样可以做到零额外成本;但传入函数指针或有状态删除器时,std::unique_ptr
的尺寸会相应增大。
由于 std::unique_ptr
独占所有权的特性,其拷贝构造和拷贝赋值被禁用,只允许移动语义。
下面给出一个简易的 std::unique_ptr 的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| #include <utility>
template <typename T> struct DefaultDelete { void operator()(T* p) const { delete p; } };
template <typename T, typename Deleter = DefaultDelete<T>> class MyUniquePtr : private Deleter { private: T* ptr_ = nullptr;
public: explicit MyUniquePtr(T* p = nullptr, Deleter d = Deleter()) : Deleter(std::move(d)), ptr_(p) {}
~MyUniquePtr() { if (ptr_) { Deleter::operator()(ptr_); } }
MyUniquePtr(const MyUniquePtr&) = delete; MyUniquePtr& operator=(const MyUniquePtr&) = delete;
MyUniquePtr(MyUniquePtr&& other) noexcept : Deleter(std::move(other)), ptr_(other.ptr_) { other.ptr_ = nullptr; }
MyUniquePtr& operator=(MyUniquePtr&& other) noexcept { if (this != &other) { if (ptr_) Deleter::operator()(ptr_);
static_cast<Deleter&>(*this) = std::move(other); ptr_ = std::exchange(other.ptr_, nullptr); } return *this; }
T& operator*() const { return *ptr_; } T* operator->() const { return ptr_; } T* get() const { return ptr_; } };
|
std::unique_ptr
的工作原理很简单:内部保存一份原始指针供外部操作,并禁用拷贝、只允许移动以保证资源的独占性;生命周期结束时编译器会自动调用其析构函数释放资源,实现资源的自动回收。
std::unique_ptr 默认使用一个简单的删除器,其作用就是调用
delete 释放内存。
std::unique_ptr
支持自定义删除器。自定义删除器可以是普通函数,也可以是 lambda
匿名函数或仿函数。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| #include <memory>
struct MyResource {};
void freeResourceFunc(MyResource* p) { delete p; }
struct ResourceDeleterFunctor { void operator()(MyResource* p) const { delete p; } };
using FuncPtr = void(*)(MyResource*); std::unique_ptr<MyResource, FuncPtr> ptr1(new MyResource(), freeResourceFunc);
std::unique_ptr<MyResource, ResourceDeleterFunctor> ptr2(new MyResource());
auto statelessLambda = [](MyResource* p) { delete p; }; std::unique_ptr<MyResource, decltype(statelessLambda)> ptr3(new MyResource(), statelessLambda);
int contextId = 42; auto statefulLambda = [contextId](MyResource* p) { delete p; }; std::unique_ptr<MyResource, decltype(statefulLambda)> ptr4(new MyResource(), statefulLambda);
|
自定义删除器的参数为所管理类型的指针,其内部除了释放指针外,还可以执行其他操作,比如日志打印等。
如果传入普通的函数指针,std::unique_ptr
内部会额外保存一份函数指针,产生额外的内存占用。
如果传入的是仿函数或 lambda
匿名函数,实际上是传入了一个对象实体:无状态条件下(对象没有捕获变量),编译器会进行
EBO
空基类优化,不产生额外的内存占用;有状态情况下,则会产生额外的内存占用。
std::shared_ptr
std::shared_ptr 是共享所有权的指针。与
std::unique_ptr 不同,std::shared_ptr
确实带来了运行时的额外开销。
std::shared_ptr
通过引用计数来实现资源的共享所有权管理:当最后一个指向资源的
std::shared_ptr 被销毁或重置时,该资源就会被自动释放。
std::shared_ptr 的简易实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139
| #include <utility> #include <atomic>
template <typename T> struct DefaultDelete { void operator()(T* p) const { delete p; } };
struct ControlBlockBase { std::atomic<size_t> ref_count_{1}; std::atomic<size_t> weak_count_{1};
virtual void dispose(void* p) = 0;
void release_block() noexcept { if (weak_count_.fetch_sub(1, std::memory_order_acq_rel) == 1) { delete this; } }
virtual ~ControlBlockBase() = default; };
template <typename T, typename Deleter = DefaultDelete<T>> class ControlBlock : public ControlBlockBase, private Deleter { public: explicit ControlBlock(Deleter d) : Deleter(std::move(d)) {}
void dispose(void* p) override { Deleter::operator()(static_cast<T*>(p)); } };
template <typename T> class MySharedPtr { private: T* ptr_ = nullptr; ControlBlockBase* cb_ = nullptr;
void release() noexcept { if (cb_) { if (cb_->ref_count_.fetch_sub(1, std::memory_order_acq_rel) == 1) { cb_->dispose(ptr_); cb_->release_block(); } ptr_ = nullptr; cb_ = nullptr; } }
public: constexpr MySharedPtr() noexcept = default;
template <typename Deleter = DefaultDelete<T>> explicit MySharedPtr(T* p, Deleter d = Deleter()) : ptr_(p) { if (ptr_) { cb_ = new ControlBlock<T, Deleter>(std::move(d)); } }
~MySharedPtr() { release(); }
MySharedPtr(const MySharedPtr& other) noexcept : ptr_(other.ptr_), cb_(other.cb_) { if (cb_) { cb_->ref_count_.fetch_add(1, std::memory_order_relaxed); } }
MySharedPtr& operator=(const MySharedPtr& other) noexcept { if (this != &other) { release();
ptr_ = other.ptr_; cb_ = other.cb_; if (cb_) { cb_->ref_count_.fetch_add(1, std::memory_order_relaxed); } } return *this; }
MySharedPtr(MySharedPtr&& other) noexcept : ptr_(std::exchange(other.ptr_, nullptr)), cb_(std::exchange(other.cb_, nullptr)) {}
MySharedPtr& operator=(MySharedPtr&& other) noexcept { if (this != &other) { release();
ptr_ = std::exchange(other.ptr_, nullptr); cb_ = std::exchange(other.cb_, nullptr); } return *this; }
T& operator*() const noexcept { return *ptr_; } T* operator->() const noexcept { return ptr_; } T* get() const noexcept { return ptr_; }
size_t use_count() const noexcept { return cb_ ? cb_->ref_count_.load(std::memory_order_relaxed) : 0; }
explicit operator bool() const noexcept { return ptr_ != nullptr; }
private: MySharedPtr(T* p, ControlBlockBase* cb) noexcept : ptr_(p), cb_(cb) { if (cb_) { cb_->ref_count_.fetch_add(1, std::memory_order_relaxed); } }
friend class MyWeakPtr<T>; };
|
std::shared_ptr
的原理是其内部存储了两份指针:一份指向资源地址,另一份指向一个控制块。控制块位于堆内存中,其内部包括强引用计数、弱引用计数以及自定义删除器等。
当发生拷贝赋值或拷贝构造时,对应控制块的强引用计数会增加;当
std::shared_ptr
的生命周期结束时,对应控制块的强引用计数会减少。强引用计数归零时资源会被释放;当强引用和弱引用全部归零时,控制块内存也会被释放。
与 std::unique_ptr 相比,std::shared_ptr
显然更加灵活,但是也付出了相应的代价:
尺寸是裸指针的两倍:因为必须同时存储”资源指针”和”控制块指针”。
控制块需要动态内存分配:创建 std::shared_ptr
时,如果没有现成的控制块,就必须在堆上额外分配一次内存(除非使用
std::make_shared 将对象和控制块合并分配)。
引用计数的操作必须是原子性的:递增/递减引用计数需要原子操作,在多线程环境下存在
CPU 缓存同步的开销。
拷贝操作比 std::unique_ptr 昂贵:拷贝
std::shared_ptr
时需要对引用计数执行原子递增;移动操作则无需修改原子计数,只涉及指针赋值。
与 std::unique_ptr 相比,std::shared_ptr
的构造也更加灵活:自定义删除器不再作为模板参数传递,删除器类型不再是指针类型的一部分,使得属于同一类型的
std::shared_ptr 可以放入同一个容器内。
需要注意的是,用同一份资源初始化不同的 std::shared_ptr
时,会多次分配控制块内存,每个 std::shared_ptr
独立计数。
1 2 3
| Widget* rawPtr = new Widget(); std::shared_ptr<Widget> sp1(rawPtr); std::shared_ptr<Widget> sp2(rawPtr);
|
此时两者析构时,裸指针会被重复释放,导致未定义行为。正确做法是直接使用
std::make_shared<Widget>();若必须手动
new,则应在 new
之后立即用同一个表达式初始化唯一的
std::shared_ptr:std::shared_ptr<Widget> sp1(new Widget());。
还有一点需要注意,当你在类内用 this 指针初始化
std::shared_ptr 时,也会创建多个控制块。比如:
1 2 3 4 5 6 7 8 9 10 11 12 13
| std::vector<std::shared_ptr<Widget>> processedWidgets;
class Widget { public: ... void process(); ... };
void Widget::process() { ... processedWidgets.emplace_back(this); }
|
此时每次调用 process 函数,都会利用 this
裸指针创建一份新的控制块;当这些 std::shared_ptr
析构时会重复释放同一资源,导致未定义行为。
正确做法是通过继承 std::enable_shared_from_this<T>
并调用
shared_from_this(),让对象共享已有的控制块,使引用计数正确地
+1。
1 2 3 4 5 6 7 8 9 10 11 12 13
| std::vector<std::shared_ptr<Widget>> processedWidgets;
class Widget : public std::enable_shared_from_this<Widget> { public: ... void process(); ... };
void Widget::process() { ... processedWidgets.emplace_back(shared_from_this()); }
|
当外部的 std::shared_ptr
构造函数初始化该对象时,会发现对象继承了
std::enable_shared_from_this,从而把控制块的地址写入对象内部的
weak_this 成员。之后类内调用
shared_from_this() 时,会基于已有控制块构造一个新的
std::shared_ptr
并增加强引用计数。由此也可以理解:为什么在构造函数中调用
shared_from_this() 会出错(此时外部的
std::shared_ptr 尚未接管对象,weak_this
还是空的),以及为什么类外从未用 std::shared_ptr
管理过对象时不能调用 shared_from_this()。
std::weak_ptr
std::weak_ptr 是一种区别于 std::unique_ptr
和 std::shared_ptr
的指针,它不具有对象的所有权。std::weak_ptr 通常伴随着
std::shared_ptr 使用:std::weak_ptr
不增加强引用计数,而是增加弱引用计数。
简单来说,std::weak_ptr
是观察者视角:它可以观察对象的存活情况但不能直接操作,当需要操作对象时可以提升为
std::shared_ptr。
std::weak_ptr 的简易实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78
| template <typename T> class MyWeakPtr { private: T* ptr_ = nullptr; ControlBlockBase* cb_ = nullptr;
void release() noexcept { if (cb_) { cb_->release_block(); ptr_ = nullptr; cb_ = nullptr; } }
public: MyWeakPtr() noexcept = default;
MyWeakPtr(const MySharedPtr<T>& sp) noexcept : ptr_(sp.ptr_), cb_(sp.cb_) { if (cb_) { cb_->weak_count_.fetch_add(1, std::memory_order_relaxed); } }
MyWeakPtr(const MyWeakPtr& other) noexcept : ptr_(other.ptr_), cb_(other.cb_) { if (cb_) { cb_->weak_count_.fetch_add(1, std::memory_order_relaxed); } }
MyWeakPtr(MyWeakPtr&& other) noexcept : ptr_(std::exchange(other.ptr_, nullptr)), cb_(std::exchange(other.cb_, nullptr)) {}
MyWeakPtr& operator=(const MyWeakPtr& other) noexcept { if (this != &other) { release(); ptr_ = other.ptr_; cb_ = other.cb_; if (cb_) { cb_->weak_count_.fetch_add(1, std::memory_order_relaxed); } } return *this; }
MyWeakPtr& operator=(MyWeakPtr&& other) noexcept { if (this != &other) { release(); ptr_ = std::exchange(other.ptr_, nullptr); cb_ = std::exchange(other.cb_, nullptr); } return *this; }
~MyWeakPtr() { release(); }
bool expired() const noexcept { return !cb_ || cb_->ref_count_.load(std::memory_order_relaxed) == 0; }
MySharedPtr<T> lock() const noexcept { if (expired()) return MySharedPtr<T>(); return MySharedPtr<T>(ptr_, cb_); } };
|
从实现可以看出:std::weak_ptr
的拷贝只影响弱引用计数,不会阻止资源被释放;而 lock()
是进入”操作模式”的唯一入口,且必须先检查对象是否存活。
std::weak_ptr 主要有两大核心应用场景:
1. 缓存 / 观察者模式
当我们缓存了一个指针并需要使用时,我们需要知道指针指向对象的存活情况:如果使用裸指针,我们无法得知指针指向的对象内存是否已经被释放;如果使用
std::shared_ptr,则会导致对象内存永远不被释放。
std::weak_ptr
可以观察对象存活情况而不干扰对象生命周期;需要操作对象时,先通过
lock() 判断对象是否存活。
1 2 3 4 5 6 7 8 9 10 11 12
| std::shared_ptr<const Widget> fastLoadWidget(WidgetID id) { static std::unordered_map<WidgetID, std::weak_ptr<const Widget>> cache;
auto objPtr = cache[id].lock();
if (!objPtr) { objPtr = loadWidget(id); cache[id] = objPtr; } return objPtr; }
|
利用 std::weak_ptr 缓存指针,使用时通过
lock() 将 std::weak_ptr 提升为
std::shared_ptr:提升成功则直接返回,失败则重新加载,避免了使用悬空指针的风险。
2. 避免循环引用
std::shared_ptr
的使用有时会导致循环引用,使内存无法释放。严格来说这是内存泄漏,并非并发编程意义上的死锁。
我们设计这样一段代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| struct A { std::shared_ptr<B> b_ptr; ~A() { std::cout << "A 析构了\n"; } };
struct B { std::shared_ptr<A> a_ptr; ~B() { std::cout << "B 析构了\n"; } };
int main() { { auto a = std::make_shared<A>(); auto b = std::make_shared<B>();
a->b_ptr = b; b->a_ptr = a; } }
|
存在 A 和 B 两种结构体,其内部分别持有指向对方的
std::shared_ptr 指针 b_ptr 和
a_ptr。
首先,我们创建 A 和 B 的实例,并且使用 std::shared_ptr
变量 a 和 b 指向它们。此时 A 和 B
的强引用计数都为 1。
随后,我们让 A 内部的 b_ptr 拷贝 b,B
内部的 a_ptr 拷贝 a,此时由于
std::shared_ptr 发生拷贝,A 和 B 的强引用计数都变为 2。
当离开作用域时:
- 栈上的局部变量
b 析构,对象 B 的引用计数从 2 降为
1(a->b_ptr 还在持有 B)。
- 栈上的局部变量
a 析构,对象 A 的引用计数从 2 降为
1(b->a_ptr 还在持有 A)。
此时陷入了一个僵局:
- 想要释放对象 A → 必须让 A 的强引用计数归 0 → 必须先销毁持有 A 的对象
B。
- 想要释放对象 B → 必须让 B 的强引用计数归 0 → 必须先销毁持有 B 的对象
A。
结果:两者互相僵持,引用计数永远卡在
1,任何一方的析构函数都无法被调用,分配在堆上的 A 和 B
内存彻底泄漏。
解决方法是使 A 或 B 一方的反向指针改为
std::weak_ptr:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| struct A { std::shared_ptr<B> b_ptr; ~A() { std::cout << "A 析构了\n"; } };
struct B { std::weak_ptr<A> a_ptr; ~B() { std::cout << "B 析构了\n"; } };
int main() { { auto a = std::make_shared<A>(); auto b = std::make_shared<B>();
a->b_ptr = b; b->a_ptr = a; } }
|
此时,离开作用域时栈上的局部变量 b 先析构,B
的强引用计数从 2 降为 1(a->b_ptr 还在持有 B);随后
a 析构,A 的强引用计数从 1 降为 0,A 析构后其内部的
b_ptr 被销毁,B 的计数也降为 0,B 成功析构。
智能指针的构造
与用 new 初始化对象作为构造智能指针的参数相比,使用
std::make_unique 和 std::make_shared
往往更好。
make 系列函数构造智能指针主要具有三大优势:
1. 代码更简洁,避免冗余重复类型
手写 new 需要重复书写目标类型,而 make 函数配合
auto 能让代码极其简洁:
1 2 3 4 5
| std::unique_ptr<Widget> w1(new Widget());
auto w2 = std::make_unique<Widget>();
|
2. 保证异常安全性
在 C++17 以前,函数参数的求值顺序是未定义的,使用 new
可能会导致内存泄漏:
1 2 3 4
| void processWidget(std::shared_ptr<Widget> sp, int priority);
processWidget(std::shared_ptr<Widget>(new Widget), computePriority());
|
像这段代码的执行顺序可能是:
- 执行
new Widget 在堆上分配内存。
- 执行
computePriority()(如果此时该函数抛出异常!)。
- 调用
std::shared_ptr 的构造函数。
当第二步抛出异常后,std::shared_ptr
的构造函数不会再执行,此时 Widget
便无法执行析构,发生了内存泄漏。
如果使用 std::make_shared:
1
| processWidget(std::make_shared<Widget>(), computePriority());
|
std::make_shared
将内存分配与智能指针初始化结合在一起,彻底规避了内存泄漏风险。(C++17
起规定了函数参数的求值顺序,即使手写 new 也不会泄漏,但
make_shared 的写法依然更优。)
3. 减少内存分配次数,提升性能
由于 std::shared_ptr 需要控制块,如果使用
new
作为构造参数,对象和控制块会进行两次内存分配,开销较大;而使用
std::make_shared
只会分配一次,将对象和控制块合并分配,开销更小。
但是有时也必须直接使用 new,make 函数不再适用:
1. 需要指定自定义删除器
std::make_shared 和 std::make_unique 的 API
不支持传入自定义删除器。如果你需要自定义释放逻辑,必须直接使用
new:
1 2 3 4
| auto customDeleter = [](Widget* pw) { releaseWidget(pw); };
std::shared_ptr<Widget> sp(new Widget(), customDeleter);
|
2. 构造函数需要初始化列表
make 函数内部通过模板完美转发(perfect
forwarding)参数,但完美转发无法推导 {1, 2, 3}
这种花括号初始化列表——花括号列表本身没有类型,它向
std::initializer_list<int>
的隐式转换只在非模板函数或明确声明为
std::initializer_list<T> 参数时才会发生。
1 2 3 4 5
| auto sp = std::make_shared<std::vector<int>>{1, 2, 3};
auto sp = std::make_shared<std::vector<int>>(std::initializer_list<int>{1, 2, 3});
|
3. 类的 new /
delete 运算符被重载
std::make_shared 内部分配的尺寸是
sizeof(Widget) 加上控制块的大小,这会导致类自定义的
operator new
无法正常匹配(分配尺寸与自定义重载期望的不同)。
4. 内存敏感场景下的延迟释放
std::make_shared
把对象和控制块分配在同一块连续内存中。对象占用的内存必须等到强引用计数和弱引用计数全部降为
0
时,整块内存才能返还给操作系统。当对象的强引用计数归零时,对象会被析构;但如果还存在
std::weak_ptr
持有该控制块(弱引用未归零),对象占用的内存将不会被释放,直到最后一个
std::weak_ptr 销毁。
所以,如果管理大对象且存在长生命周期的
std::weak_ptr,应退回手写 new(即
std::shared_ptr<Widget>(new Widget()))。这样当强引用归零时,Widget
占用的内存会立刻释放,仅保留几十字节的控制块供
std::weak_ptr 检查。
这次的插图来自画师 TheProtagonists
图片地址:https://www.pixiv.net/artworks/147800615
文章内容参考:Effective Modern C++ 42 Specific Ways to
Improve Your Use of C++11 and C++14 (Scott Meyers) Item
18、Item 19、Item 20、Item 21