智能指针
C++ 中的智能指针(Smart Pointers)是 C++11 标准引入的重要特性,主要用于自动、安全地管理动态分配的内存。它们包含在 <memory> 头文件中
智能指针的核心思想是 RAII(Resource Acquisition Is Initialization,资源获取即初始化):将动态分配的内存封装在一个局部对象中,利用局部对象在离开作用域时自动调用析构函数的特性,来实现内存的自动释放。这能有效防止内存泄漏(Memory Leak)和悬垂指针(Dangling Pointer)
| 智能指针 |
64 位大小 |
内部存储 |
unique_ptr<T>(默认删除器) |
8 |
一个裸指针 |
shared_ptr<T> |
16 |
对象指针 + 控制块指针 |
weak_ptr<T> |
16 |
同上(控制块指针 + 对象指针) |
裸指针 T* |
8 |
一个指针 |
1 std::unique_ptr
std::unique_ptr 是一种独占性的智能指针。这意味着在任何时刻,一个对象只能被一个 unique_ptr 所拥有
- 不可拷贝:你不能直接将一个
unique_ptr 赋值或拷贝给另一个 unique_ptr(拷贝构造函数和拷贝赋值运算符被禁用)
- 可移动:可以使用
std::move 将所有权从一个 unique_ptr 转移给另一个。转移后,原来的指针变为空(nullptr)
推荐使用 std::make_unique(C++14 引入)来创建
| #include <iostream>
#include <memory>
class MyClass {
public:
MyClass() { std::cout << "MyClass Constructor\n"; }
~MyClass() { std::cout << "MyClass Destructor\n"; }
void doSomething() { std::cout << "Doing something...\n"; }
};
int main() {
{
// 推荐:使用 make_unique 创建 unique_ptr
std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>();
ptr1->doSomething();
// std::unique_ptr<MyClass> ptr2 = ptr1; // 错误:无法拷贝!
// 正确:通过 std::move 转移所有权
std::unique_ptr<MyClass> ptr3 = std::move(ptr1);
if (!ptr1) {
std::cout << "ptr1 is now null.\n";
}
ptr3->doSomething();
} // 这里离开作用域,ptr3 被销毁,MyClass的析构函数自动被调用
return 0;
}
|
1.1 底层实现
内部就存一个 T*,禁止拷贝、允许移动
| template <typename T>
class unique_ptr {
T* ptr;
public:
explicit unique_ptr(T* p = nullptr) : ptr(p) {}
~unique_ptr() {
delete ptr; // 析构自动释放(RAII 核心)
}
// 禁止拷贝
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
// 允许移动:转移所有权,原指针置空
unique_ptr(unique_ptr&& other) noexcept : ptr(other.ptr) {
other.ptr = nullptr; // 关键:防止双重释放
}
unique_ptr& operator=(unique_ptr&& other) noexcept {
if (this != &other) {
delete ptr; // 先释放自己的旧资源
ptr = other.ptr; // 接管
other.ptr = nullptr; // 源置空
}
return *this;
}
// 像指针一样使用
T* operator->() const { return ptr; }
T& operator*() const { return *ptr; }
T* get() const { return ptr; }
explicit operator bool() const { return ptr != nullptr; }
};
|
核心要点:
- 析构函数里
delete ptr——离开作用域自动释放
- 移动构造/赋值把
other.ptr 置 nullptr,保证同一时刻只有一个 unique_ptr 拥有对象
- 真实的
unique_ptr 还带一个删除器(默认为 std::default_delete<T>),通过空基类优化(EBO)做到零额外开销(sizeof(unique_ptr<T>) == sizeof(T*))
1.2 unique_ptr 的大小
结论:默认情况下,sizeof(std::unique_ptr<T>) 等于一个裸指针的大小——64 位系统是 8 字节,32 位系统是 4 字节
原因在于默认删除器 std::default_delete<T> 是一个 空类(无任何成员),通过 空基类优化(EBO, Empty Base Optimization),它在 unique_ptr 内部不占任何空间。所以 unique_ptr 实际上只存了一个裸指针
| #include <iostream>
#include <memory>
int main() {
std::cout << sizeof(std::unique_ptr<int>) << '\n'; // 8(64 位系统)
std::cout << sizeof(int*) << '\n'; // 8(相等)
std::cout << sizeof(std::unique_ptr<double>) << '\n'; // 8(与 T 类型无关)
}
|
unique_ptr 的核心设计目标就是 零额外开销——它的大小、解引用速度、访问速度都应当和一个裸指针完全一致。默认删除器是空类型,配合 EBO 后,unique_ptr<T> 的内部布局等价于:
┌──────────────┐
│ T* ptr │ ← 就一个指针
└──────────────┘
这也包括数组版本:sizeof(std::unique_ptr<int[]>) 同样是 8 字节(default_delete<T[]> 也是空类)
如果传入 有状态 的删除器,unique_ptr 的大小会变成"指针 + 删除器":
| 删除器类型 |
sizeof(unique_ptr) |
说明 |
默认 std::default_delete<T> |
8 |
空类,EBO 不占空间 |
| 无捕获 lambda / 空仿函数 |
8 |
也是空类型,EBO 生效 |
函数指针 void(*)(T*) |
16 |
必须额外存一个函数指针 |
| 带捕获的 lambda |
12~16+ |
取决于捕获的成员大小 + 对齐 |
| #include <iostream>
#include <memory>
int main() {
// 1. 默认删除器:8 字节
std::cout << sizeof(std::unique_ptr<int>) << '\n'; // 8
// 2. 函数指针删除器:16 字节(指针 + 函数指针)
using FP = void(*)(int*);
std::cout << sizeof(std::unique_ptr<int, FP>) << '\n'; // 16
// 3. 无捕获 lambda(空类型):8 字节,EBO 生效
auto emptyDel = [](int* p) { delete p; };
std::cout << sizeof(std::unique_ptr<int, decltype(emptyDel)>) << '\n'; // 8
// 4. 带捕获 lambda:捕获了一个 int → 8 + 4 对齐到 16
int n = 0;
auto capDel = [n](int* p) { delete p; };
std::cout << sizeof(std::unique_ptr<int, decltype(capDel)>) << '\n'; // 16
}
|
建议
如果自定义删除器不需要携带状态,尽量用 无捕获 lambda 或空仿函数,这样能保持 unique_ptr 的零开销(还是 8 字节)。用 函数指针 做删除器会额外占 8 字节,是常见的小浪费
2 std::shared_ptr
std::shared_ptr 允许多个智能指针共享同一个对象的所有权
- 内部包含一个引用计数器(Reference Count)
- 每次对
shared_ptr 进行拷贝或赋值操作时,引用计数加 1
- 每次有
shared_ptr 被销毁(离开作用域)或重新赋值时,引用计数减 1
- 当引用计数降为 0 时,对象才会被自动销毁
推荐使用 std::make_shared 来创建。它不仅使得代码更简洁,还能将对象实例和控制块(包含引用计数等信息)合并为一次内存分配,性能更好且能防止某些极端情况下的内存泄漏
make_shared 一次分配
当你使用 shared_ptr 管理一个对象时,底层其实需要维护两块信息:
- 对象实例本身:也就是你真正想使用的数据(比如一个
int,或者一个 MyClass 对象)
-
控制块(Control Block):它是 shared_ptr 用来实现共享和计数的东西。控制块里包含:
- 强引用计数(有多少个
shared_ptr 指向对象)
- 弱引用计数(有多少个
weak_ptr 观察对象)
- 自定义删除器(如果有的话)等其他信息
如果你这样写代码:
| std::shared_ptr<MyClass> ptr(new MyClass());
|
实际上发生了两步:
new MyClass() 在堆内存中开辟了一块空间,用来存放 MyClass 对象。这是第一次内存分配
shared_ptr 的构造函数收到这个裸指针后,为了管理它,又在堆内存中单独开辟了另一块空间,用来存放“控制块”。这是第二次内存分配
两者的内存布局时分散的
如果你这样写代码:
| auto ptr = std::make_shared<MyClass>();
|
std::make_shared 会在底层进行优化。它会先计算好“对象的大小”加上“控制块的大小”,然后向系统申请一整块足够大的连续内存,把控制块和对象挨着放在一起。两者的内存布局时连续的
- 性能更高(省时间):在堆上分配内存(调用
new 或 malloc)是一个相对耗时的操作系统操作。把两次分配减为一次分配,直接减少了一半的内存分配开销
- 内存更连续,缓存命中率高(省空间、速度快):控制块和对象在内存中是挨着的。当 CPU 读取
shared_ptr 的控制块检查引用计数时,利用局部性原理,对象本身大概率也会被顺便加载到 CPU 的高速缓存(Cache)中,后续访问对象数据的速度会非常快
- 异常安全(防止内存泄漏):在早期的 C++11/14 中,如果写一个函数:
func(std::shared_ptr<T>(new T()), doSomethingElse());。如果 new T() 成功了,但在构造 shared_ptr 之前,doSomethingElse() 抛出了异常,那么 new T() 申请的内存就会泄漏,因为没有指针接管它。而 make_shared 把分配和接管做成了一个不可分割的整体动作,消除了这个风险
| #include <iostream>
#include <memory>
int main() {
std::shared_ptr<int> ptr1 = std::make_shared<int>(100);
std::cout << "Count (ptr1): " << ptr1.use_count() << "\n"; // 输出: 1
{
std::shared_ptr<int> ptr2 = ptr1; // 发生拷贝,共享所有权
std::cout << "Count (ptr1): " << ptr1.use_count() << "\n"; // 输出: 2
*ptr2 = 200;
} // ptr2 离开作用域被销毁,引用计数减1
std::cout << "Count (ptr1): " << ptr1.use_count() << "\n"; // 输出: 1
std::cout << "Value: " << *ptr1 << "\n"; // 输出: 200
return 0; // ptr1 被销毁,引用计数降为0,释放内存
}
|
自定义删除器
智能指针默认使用 delete 释放资源,但有时我们管理的不一定是普通内存(例如文件句柄 FILE*、网络套接字)。此时可以传入自定义删除器:
| // unique_ptr 的删除器是模板类型的一部分,需要明确指定
std::unique_ptr<FILE, decltype(&fclose)> filePtr(fopen("test.txt", "r"), &fclose);
// shared_ptr 的删除器不需要写在模板参数里,更灵活
std::shared_ptr<FILE> filePtr2(fopen("test.txt", "r"), fclose);
|
shared_ptr 是线程安全的吗
- 控制块的引用计数是线程安全的:底层通常使用原子操作(
std::atomic)实现计数的增减,因此多个线程同时拷贝、析构同一个 shared_ptr 是安全的
- 指针指向的对象的读写不是线程安全的:多个线程通过
shared_ptr 并发修改其指向的底层对象时,如果没有加锁同步,会引发数据竞争(Data Race)。此外,对同一个 shared_ptr 实例本身进行并发读写操作(如一个线程赋值,另一个线程读取)也是不安全的,需要加锁
2.1 底层实现
shared_ptr 允许多个指针共享同一对象,所以需要引用计数。它内部持有两个指针:
| shared_ptr 对象:
┌─────────────────┐
│ ptr(对象指针) │ ──→ 被管理的对象
├─────────────────┤
│ ctrl(控制块指针)│ ──→ 控制块 ControlBlock
└─────────────────┘
|
| template <typename T>
struct ControlBlock {
size_t strong_count; // 强引用计数:有多少个 shared_ptr 指向对象
size_t weak_count; // 弱引用计数:有多少个 weak_ptr 在观察
// (真实实现里还有:删除器、分配器等,这里简化)
};
|
| template <typename T>
class shared_ptr {
T* ptr; // 指向对象
ControlBlock* ctrl; // 指向控制块
public:
// 构造函数:初始化引用计数为 1
explicit shared_ptr(T* p)
: ptr(p), ctrl(new ControlBlock{1, 0}) {}
// 拷贝构造:强引用计数 +1
shared_ptr(const shared_ptr& other)
: ptr(other.ptr), ctrl(other.ctrl) {
++ctrl->strong_count;
}
// 拷贝赋值:先处理旧的,再处理新的
shared_ptr& operator=(const shared_ptr& other) {
if (this != &other) {
release(); // 释放自己原来的引用
ptr = other.ptr;
ctrl = other.ctrl;
++ctrl->strong_count; // 增加新的引用
}
return *this;
}
// 析构:强引用计数 -1,为 0 时销毁对象
~shared_ptr() { release(); }
void release() {
if (--ctrl->strong_count == 0) {
delete ptr; // ① 强引用归零 → 销毁对象
if (ctrl->weak_count == 0)
delete ctrl; // ② 弱引用也归零 → 销毁控制块
}
}
size_t use_count() const { return ctrl->strong_count; }
T* get() const { return ptr; }
T* operator->() const { return ptr; }
T& operator*() const { return *ptr; }
};
|
2.2 shared_ptr 的大小
shared_ptr 需要同时维护"对象指针"和"控制块指针",所以通常是 两个指针的大小(64 位下 16 字节):
| std::cout << sizeof(std::shared_ptr<int>) << '\n'; // 16(对象指针 + 控制块指针)
std::cout << sizeof(std::unique_ptr<int>) << '\n'; // 8(只有对象指针)
|
3 std::weak_ptr
std::weak_ptr 是为了配合 shared_ptr 而设计的。它提供对象的“非拥有”(观察者)访问权
- 不增加引用计数:将一个
shared_ptr 赋值给 weak_ptr 不会改变对象的生命周期
- 不能直接访问:
weak_ptr 没有重载 * 和 -> 操作符。要想访问对象,必须先通过 lock() 方法尝试将其提升为一个有效的 shared_ptr
- 核心作用:解决
shared_ptr 带来的循环引用(Circular Reference)问题
循环引用
如果对象 A 包含指向对象 B 的 shared_ptr,而对象 B 也包含指向对象 A 的 shared_ptr,那么它们的引用计数永远不会降为 0,从而导致内存泄漏。此时应将其中一个指针改为 weak_ptr 来打破循环
| #include <iostream>
#include <memory>
struct Node {
std::shared_ptr<Node> next;
// 如果这里使用 std::shared_ptr<Node> prev; 会导致循环引用
std::weak_ptr<Node> prev; // 使用 weak_ptr 打破循环
~Node() { std::cout << "Node Destroyed\n"; }
};
int main() {
auto node1 = std::make_shared<Node>();
auto node2 = std::make_shared<Node>();
node1->next = node2;
node2->prev = node1;
// 检查 prev 的对象是否还有效
if (auto shared_prev = node2->prev.lock()) {
std::cout << "prev node is still alive.\n";
}
return 0;
// 正常退出,node1 和 node2 均被正确释放(输出两次 "Node Destroyed")
}
|
lock()
要想使用被 weak_ptr 指向的对象的方法,你必须先使用 lock() 方法。lock() 的作用是:检查观察的对象是否还存活
- 如果对象还存活:它会返回一个有效的
std::shared_ptr(同时会临时将引用计数加 1,保证在你使用期间对象绝对不会被其他地方销毁)
- 如果对象已经销毁:它会返回一个空的
std::shared_ptr
3.1 底层实现
weak_ptr 也指向控制块,但不增加强引用计数,只增加弱引用计数
| template <typename T>
class weak_ptr {
T* ptr; // 对象指针(可能已失效)
ControlBlock* ctrl; // 控制块指针
public:
// 从 shared_ptr 构造:只增加 weak_count,不动 strong_count
weak_ptr(const shared_ptr<T>& sp)
: ptr(sp.get()), ctrl(sp.ctrl) {
++ctrl->weak_count;
}
~weak_ptr() {
if (--ctrl->weak_count == 0 && ctrl->strong_count == 0)
delete ctrl; // 两个计数都归零才销毁控制块
}
// 关键:lock() 检查对象是否还活着
shared_ptr<T> lock() const {
if (ctrl->strong_count > 0) { // 对象还活着
++ctrl->strong_count; // 临时 +1,保证使用期间不被销毁
return shared_ptr<T>(ptr, ctrl);
}
return shared_ptr<T>(); // 对象已销毁,返回空
}
bool expired() const { return ctrl->strong_count == 0; }
};
|
std::auto_ptr 为什么被 C++11 废弃最终在 C++17 移除
auto_ptr 是早期的独占式智能指针。它的拷贝操作具有破坏性(会隐式地转移所有权)
auto_ptr<int> p2 = p1; 编译能通过,但赋值后,p1 会莫名其妙变成空指针。如果把 auto_ptr 放进 STL 容器(如 vector)并调用算法(如 sort 内部会产生拷贝),会导致严重崩溃危险
C++11 引入右值引用和移动语义后,用 unique_ptr 的 std::move 明确区分了拷贝和转移的概念,从而完美替代了它