小白也能看懂的C++中的智能指针,异常以及删除器

前言
| 别再用裸指针 new/delete 了,除非你想在凌晨三点盯着 core dump 怀疑人生。 本文会手把手带你搞懂:异常安全、unique_ptr、shared_ptr、weak_ptr、release() vs delete、数组特化、删除器、lambda、decltype、包装器…… 所有看不懂的高阶概念,都会在出现时给出“小白补给站”。 |
0.一段真实的悲伤故事
2022 年某个深夜,我同事老王的服务突然内存暴涨,OOM Killer 毫不犹豫地把它干掉了。查了两天,最后发现是某个异常路径下,一个 new 出来的对象没有 delete。代码长这样:
void risky() {
Widget* p = new Widget();
do_something(); // 这里抛异常 💥
delete p;
}
经典得不能再经典的泄漏。老王幽幽说了句:“如果用智能指针,早下班了。”
所以,今天我们就来彻底搞懂:异常 + 智能指针 = 内存安全。
1. 异常 + 裸指针 = 内存泄漏的温床
C++ 异常有个牛逼特性叫栈展开:抛出异常后,从 throw 到 catch 之间,所有栈上的局部对象都会被析构。注意:只有对象,指针不算对象。
void foo() {
Widget* p = new Widget(); // 指针 p 是局部变量,但指向的内存不是
throw std::runtime_error("boom");
delete p; // 永远到不了这里
}
p 自己会被销毁(因为它是个指针变量),但 p 指向的那坨内存没人管了。这就是泄漏的根源。
解决方案:让资源拥有一个栈上的对象,由它的析构函数来释放。这就是 RAII(Resource Acquisition Is Initialization)。
智能指针就是 RAII 的典范。
🔍 小白补给站:RAII
简单说就是:“在构造函数里拿资源,在析构函数里释放资源”。这样无论函数是正常结束还是异常跳出,析构函数都会被执行,资源就不会泄漏。智能指针就是把这个思想做成了现成的工具。
2.智能指针三兄弟:保镖、保姆团、临时卡
🥷 std::unique_ptr —— 专属保镖
特点:
· 独占所有权,不能拷贝,只能移动。
· 零额外开销(和裸指针一样大)。
· 离开作用域自动 delete。
auto p = std::make_unique<Widget>();
p->doIt();
// 离开作用域,Widget 自动销毁
想转移所有权?用 std::move:
auto p2 = std::move(p); // p 变成空,p2 接手
异常安全:即使中间抛异常,p 析构时照样释放资源,稳如老狗。
👭 std::shared_ptr —— 共享保姆团
特点:
· 多个 shared_ptr 指向同一对象,内部引用计数。
· 最后一个销毁时释放对象。
· 有额外开销(控制块、原子操作)。
auto sp1 = std::make_shared<Widget>();
auto sp2 = sp1; // 引用计数变 2
sp2.reset(); // 计数变 1
sp1.reset(); // 计数变 0,Widget 被销毁
异常安全:一样安全。但注意,引用计数的操作是原子且异常的,不会因为异常而计数错误。
🎫 std::weak_ptr —— 临时阅览证
解决 shared_ptr 的循环引用问题。看这段自杀式代码:
struct Node {
std::shared_ptr<Node> next;
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->next = a; // 循环引用,永远无法释放
解决:把其中一个改成 weak_ptr。
struct Node {
std::weak_ptr<Node> next; // 不增加引用计数
};
weak_ptr 不能直接访问资源,需要先 lock() 提升为 shared_ptr:
if (auto sp = weak.lock()) {
sp->doWork();
} else {
// 资源已释放
}
比喻总结:
| 类型 | 比喻 | 开销 | 能否拷贝 |
|---|---|---|---|
unique_ptr |
专属保镖 | 零 | 否(只能移动) |
shared_ptr |
保姆团 + 计数器 | 稍高 | 能 |
weak_ptr |
临时阅览证 | 同 shared_ptr | 能 |
3. release() vs delete —— 保镖辞职与销毁赃物
很多同学分不清 release() 和 delete 的区别。我们用保镖故事讲:
· release():保镖说:“我不干了,这把枪还给你(返回裸指针),但枪还在。” 内存未释放,你需要自己接管并负责 delete。
· delete:直接销毁这把枪,枪没了(内存释放)。
std::unique_ptr<int> guard(new int(42));
int* raw = guard.release(); // 保镖放弃看守,返回裸指针,内存还在
delete raw; // 现在手动销毁(必须的,否则泄漏)
误区:很多人以为 release() 会自动释放内存 —— 不会,它只是交出所有权。
使用场景:当你要把所有权转交给一个旧的 C 接口,或者某个函数要求裸指针但你仍然想在最后手动释放。
对比 reset():reset() 会释放原对象,然后可选地接管新对象。相当于保镖换枪,旧枪销毁。
guard.reset(); // 释放原对象,guard 变空
guard.reset(new int); // 释放原对象,接管新对象
4. new[] 与特化 —— 为什么需要数组版本?
问题:new[] 必须配对 delete[]
int* arr = new int[10];
delete arr; // ❌ 未定义行为(可能只析构第一个元素,或崩溃)
delete[] arr; // ✅ 正确
普通 unique_ptr 析构时调用 delete(单个对象),如果用来管理数组会导致灾难。
解决方案:模板特化 unique_ptr<T[]>
🔍 小白补给站:模板特化
模板特化就是为某个特定类型(比如 T[])写一个专门的实现版本。语法是 template<> class MyClass<T[]> { … };。这样当你用 int[] 作为模板参数时,编译器会优先选择这个特化版本。
标准库对 unique_ptr 做了数组特化:析构时调用 delete[],并且提供了 operator[] 方便访问元素。
std::unique_ptr<int[]> arr(new int[10]);
arr[0] = 42; // 可以使用 []
// 自动调用 delete[]
模拟实现示意:
template<typename T>
class MyUniquePtr<T[]> { // 数组特化
T* ptr;
public:
~MyUniquePtr() { delete[] ptr; }
T& operator[](size_t i) { return ptr[i]; }
};
5. 删除器:当默认 delete 不够用时
默认情况下,智能指针用 delete 释放资源。但你可能会遇到:
· 用 malloc 分配的内存 → 需要 free
· FILE* 文件指针 → 需要 fclose
· 第三方库的自定义释放函数
这时你需要自定义删除器。
unique_ptr 的删除器
删除器的类型是模板参数的一部分。所以:
void myDeleter(int* p) { free(p); }
std::unique_ptr<int, decltype(&myDeleter)> p(
(int*)malloc(sizeof(int)), myDeleter
);
🔍 小白补给站:decltype
decltype(表达式) 是一个编译时的“类型侦探”。它不会计算表达式的值,只会告诉你这个表达式的精确类型。
比如 decltype(&myDeleter) 的结果是 void()(int)(函数指针类型)。这让你不需要手动写那个长长的类型。
更现代的做法:用无捕获的 lambda(C++11 起):
auto deleter = [](int* p) { free(p); };
std::unique_ptr<int, decltype(deleter)> p(
(int*)malloc(sizeof(int)), deleter
);
🔍 小白补给站:lambda
Lambda 就是“匿名函数对象”。编译器会为你生成一个名字隐藏的类,重载了 operator()。
[](int p){ free§; } 就是一个 lambda。你可以把它赋值给 auto 变量,然后像函数一样调用它。
注意:unique_ptr 要求删除器是可复制的并且通常是无状态的。无捕获 lambda 是空类(大小 1 字节),性能完美。
shared_ptr 的删除器
舒服多了:类型擦除,直接传可调用对象,不需要模板参数。
std::shared_ptr<FILE> sp(
fopen("test.txt", "r"),
[](FILE* f) { if (f) fclose(f); }
);
甚至能够处理 malloc/free:
std::shared_ptr<int> sp(
(int*)malloc(sizeof(int)), free
);
为什么 shared_ptr 能这么随意? 因为它内部通过控制块存储删除器的类型和对象,构造时就知道怎么释放,不暴露给模板参数。
🔍 小白补给站:包装器与类型擦除
“包装器”通常指 std::function,它可以存储任何可调用对象(函数、lambda、仿函数),对外统一类型。
shared_ptr 内部的删除器实现类似包装器的技术(但更高效),达到“不管传什么删除器,shared_ptr 的类型都一样”的效果。这就是类型擦除。
核心难点:为什么 shared_ptr 构造时传删除器,而 unique_ptr 必须在模板参数传?
shared_ptr 构造时传删除器,而 unique_ptr 必须在模板参数传?
这是 C++ 设计的一个精妙取舍。我们通过对比理解:
| 特性 | unique_ptr |
shared_ptr |
|---|---|---|
| 删除器传递方式 | 模板参数(编译时) | 构造函数参数(运行时) |
| 删除器类型是否是类型的一部分 | ✅ 是,两个不同删除器的 unique_ptr 类型不同 |
❌ 否,类型擦除,所有 shared_ptr 类型相同 |
| 性能开销 | 零开销(调用可内联) | 有额外间接调用(通常一次函数指针调用) |
| 灵活性 | 较低,不同删除器无法赋值给同一变量 | 高,可以放进容器、作为函数参数统一类型 |
比喻:
· unique_ptr 像私人定制保镖:合同(类型)里写死了他只能用哪种枪(删除器)。换枪就得换保镖(类型不同)。优点是性能极致(零开销),缺点是换删除器就得改类型,不灵活。
· shared_ptr 像保安公司:每个保安(实例)可以临时配备不同的工具(删除器),工具存在公司档案里(控制块),对外统一都叫“保安”。优点是灵活(类型统一),代价是少许运行时开销。
面试常见陷阱:
问:“为什么 shared_ptr 的删除器不放在模板参数里?”
答:因为如果放在模板参数里,那么两个删除器不同的 shared_ptr 就不能放进同一个容器,也无法作为同一类型的函数参数,失去共享语义。设计者选择了灵活性。
7. Lambda 没有默认构造 —— 所以 decltype 帮我们推导后还得传对象?
你可能会写这样的代码:
auto del = [](int* p) { delete p; };
std::unique_ptr<int, decltype(del)> p(new int, del);
为什么不能只写 std::unique_ptr<int, decltype(del)> p(new int);?
因为 decltype(del) 得到的类型是一个没有默认构造函数的 lambda 类型(C++20 之前)。你不能凭空构造一个 lambda 对象,必须提供已有的实例。
形象理解:decltype(del) 只是问侦探:“del 的类型是什么?”侦探告诉你了,但这不代表你能凭空变出一个该类型的对象。你得给它一个活物(del 对象)。
解决方案:
· 使用函数指针(有默认构造,但性能差,因为函数指针无法内联)
· 使用自定义的空仿函数(有默认构造,且可以内联)
· C++20 允许无捕获 lambda 默认构造,但实践还不广泛。
struct MyDeleter {
void operator()(int* p) const { delete p; }
};
std::unique_ptr<int, MyDeleter> p(new int); // OK,MyDeleter 可默认构造
所以,如果你懒得传 lambda 对象,就写个仿函数。
8. 包装器(std::function)到底是什么?为什么在智能指针里提到它?
🔍 小白补给站:包装器 std::function 的终极解释
你开了一家“万能计算器”公司,客户可以在上面执行任何数学运算——加法、减法、乘法……
但问题来了:这些运算来自不同的地方——有的是普通函数,有的是 lambda,有的是类的成员函数。它们的类型各不相同,你不能用一个固定的指针接收所有。
std::function 就是一个“万能插座”,它能容纳任何可调用对象,只要它们的调用形式匹配(比如都是 int(int,int))。
#include <functional>
int add(int a, int b) { return a + b; }
int main() {
std::function<int(int,int)> calculator;
calculator = add; // 普通函数
cout << calculator(3,4) << endl; // 7
calculator = [](int a, int b) { return a * b; }; // lambda
cout << calculator(3,4) << endl; // 12
struct Divide {
int operator()(int a, int b) const { return a / b; }
};
calculator = Divide{}; // 仿函数
cout << calculator(10,2) << endl; // 5
}
在 shared_ptr 里:删除器需要支持任何可调用对象,但又要保持 shared_ptr 类型统一。标准库没有直接使用 std::function(因为开销大),而是自己实现了更高效的类型擦除。但理解包装器是理解 shared_ptr 删除器机制的第一步。
9. make_shared 不支持自定义删除器 —— 为什么?怎么办?
原因:make_shared 会把对象和控制块放在同一块连续内存中(一次分配)。而自定义删除器通常用于非 new 分配的资源(如 fopen、malloc),这些资源无法与 make_shared 的内存模型兼容。所以标准库禁止了。
正确做法:放弃 make_shared,直接用 shared_ptr 构造:
auto fileDeleter = [](FILE* f) { if (f) fclose(f); };
std::shared_ptr<FILE> sp(fopen("test.txt", "r"), fileDeleter);
对于 malloc 内存:
int* raw = (int*)malloc(sizeof(int));
std::shared_ptr<int> sp(raw, free);
注意:这样做会有两次内存分配(对象一次,控制块一次),但为了自定义删除器,这是可以接受的。
10. 综合实战:写一个安全的文件句柄类
#include <memory>
#include <cstdio>
#include <iostream>
// 1. unique_ptr + 仿函数删除器
struct FileCloser {
void operator()(FILE* f) const {
if (f) {
fclose(f);
std::cout << "file closed (unique)\n";
}
}
};
// 2. shared_ptr + lambda 删除器(更简洁)
auto make_shared_file(const char* name, const char* mode) {
return std::shared_ptr<FILE>(
fopen(name, mode),
[](FILE* f) { if (f) fclose(f); }
);
}
int main() {
// unique_ptr 版本
std::unique_ptr<FILE, FileCloser> up(fopen("a.txt", "r"));
if (!up) {
std::cerr << "open failed\n";
return 1;
}
// shared_ptr 版本
auto sp = make_shared_file("b.txt", "r");
if (!sp) {
std::cerr << "open failed\n";
return 1;
}
// 无论是否抛异常,资源都会正确释放
throw std::runtime_error("oops");
} // up 和 sp 析构,文件自动关闭
11. 面试常见追问速览
Q: release() 和 delete 的区别
A: release() 放弃所有权,返回原始指针,不释放内存;delete 释放内存。
Q: unique_ptr 如何支持数组
A: 模板特化 unique_ptr<T[]>,提供 operator[],析构用 delete[]
Q:为什么 shared_ptr 删除器不在模板参数
A: 类型擦除,保持类型统一,方便容器和参数传递
Q:为什么 unique_ptr 删除器必须在模板参数
A: 追求零开销性能,编译期确定删除器并可内联
Q:lambda 做删除器为什么要传对象
A:lambda 类型无默认构造(C++20前),必须提供实例
Q:包装器是什么 std::function
A:可以装任何可调用对象,统一类型
Q:make_shared 为什么不能自定义删除器
A:一次分配的内存布局不能兼容任意删除器
更多推荐
所有评论(0)