目录
文章摘要
1.1 什么是智能指针
1.2 为什么需要智能指针(裸指针的痛点)
1)忘记释放 → 内存泄漏
(1)代码示例
(2)解析
(3)为什么这种泄漏很难发现
1️⃣ 短函数看起来没事:
2️⃣ 循环/长服务就爆了:
(4)更“真实”的泄漏:早 return、break、continue
(5)真实工程“灾难级例子”
1️⃣不泄漏 int,而是“大对象”
2️⃣ 机器人 / ROS / 服务程序(你场景很常见)
(6)总结
2)异常/多分支 return → delete 走不到(C++里非常关键)
(1)代码示例
(2)“多分支 return”为什么必泄漏
(3)例子(最典型的业务写法):
(4)“异常 throw”为什么更危险
(5)throw 发生时,C++ 到底做了什么?
1️⃣ throw ≠ return
2️⃣ 异常展开(stack unwinding)只做一件事:“只会自动析构栈对象”
(6)为什么说 throw 比 return 更危险?
1️⃣ return:你还能“看得见”
2️⃣ throw:可能来自你根本没意识到的地方
(7)对比:return vs throw(一眼记住)
(8)正确写法:用 RAII 一把解决(重点)
1️⃣ 错误写法
2️⃣ 正确写法 1:unique_ptr(最推荐)
3️⃣ 正确写法 2:用容器(工程里更常见)
(9)总结
1.3 用智能指针一把梭:为什么它能同时解决这两种问题?
1)用 unique_ptr 改写 f:再也不用手写 delete
2)用 unique_ptr 改写 g:return/throw 都不怕
3)解释
1.4 易踩雷相关点
1)new[] 必须 delete[]
2)多出口函数,手动 delete 很容易写成“漏一个分支”
1.5 总结
文章摘要
在 C++ 工程开发中,内存泄漏往往不是因为“不知道要 delete”,
而是由于多分支 return、异常 throw、长期服务循环等真实业务场景,
导致资源释放逻辑根本“走不到”。
本文从裸指针的典型使用场景出发,结合短函数、循环调用、异常传播等常见工程代码,
系统分析了裸指针在真实项目中的三类致命问题:
忘记释放、多出口控制流、异常不安全。
在此基础上,引出RAII(Resource Acquisition Is Initialization)资源获取即初始化)核心思想,并通过unique_ptr与容器的实际示例,说明为什么智能指针能够在return / throw / 正常执行等所有路径下,保证资源“必然释放”。
本文不追求语法堆砌,而是从工程实践角度出发,帮助大家真正理解:
为什么智能指针不是“语法糖”,而是现代 C++ 的底层生存法则。
1.1 什么是智能指针
智能指针本质上不是“更聪明的指针”
而是一个管理资源的类模板:
- 内部持有一个裸指针
- 在对象生命周期结束时(析构函数中)自动释放资源
- 从而避免以下经典问题:
1️⃣ 忘记
delete导致的内存泄漏
2️⃣ 多分支return导致的资源无法释放
3️⃣ 异常throw时直接跳出函数,delete永远走不到
4️⃣ 代码维护中“后来加了分支,却忘了补 delete”
智能指针解决的核心问题不是“指针好不好用”,
而是:让资源的释放行为变成“必然发生”的事情。
1.2 为什么需要智能指针(裸指针的痛点)
1)忘记释放 → 内存泄漏
(1)代码示例
void f() { int* p = new int(10); // ... 忘了 delete p; }(2)解析
new int(10):向堆申请一块内存+ 在上面构造一个 int,返回地址给p函数结束时:
p是局部变量,会自动销毁但是:销毁的是“指针变量 p”,不是 p 指向的堆内存
结果:堆上的那块内存没人再能访问(地址丢了),但它还占着内存 →内存泄漏
(3)为什么这种泄漏很难发现
1️⃣短函数看起来没事:
程序马上结束,OS 也许回收内存,你以为“没影响”
int main() { f(); return 0; }
- 进程退出
- 操作系统回收该进程占用的全部虚拟内存
- 所以你看不到“后果”
👉但这是 OS 在帮你擦屁股,不是你代码写对了
2️⃣循环/长服务就爆了:
循环泄漏 = 线性增长
for (;;) { f(); // 每次泄漏 }假设:
实际每次泄漏 ≈ 24 字节
1 秒调用 10 万次
1 秒 ≈ 2.4 MB 1 分钟 ≈ 144 MB 10 分钟 ≈ 1.4 GB👉 服务直接 OOM(内存耗尽)
如果 f() 里泄漏的是大对象(vector、图像 buffer、点云、模型),跑一会儿内存就飙升。
(4)更“真实”的泄漏:早 return、break、continue
很多泄漏不是“纯忘记 delete”,而是写着写着中途 return 了:
void f2(bool ok) { int* p = new int(10); if (!ok) return; // 这里一返回,delete 根本走不到 delete p; }(5)真实工程“灾难级例子”
1️⃣不泄漏 int,而是“大对象”
void f() { char* buf = new char[1024 * 1024]; // 1MB // 忘记 delete[] }for (;;) { f(); // 每次泄漏 1MB }几秒钟直接炸。
2️⃣ 机器人 / ROS / 服务程序(你场景很常见)
ROS node 一跑就是几小时 / 几天
回调函数里 new 了东西
忘记释放或异常提前 return
👉这类 bug 在机器人系统里极其致命
(6)总结
int在大多数平台是 4 字节,但一次new实际分配的内存通常大于 4 字节;短程序退出时操作系统会回收内存掩盖问题,而在循环或长期运行的服务中,微小泄漏会不断累积,最终导致内存耗尽,因此必须通过RAII / 智能指针来保证异常安全和资源自动释放。
2)异常/多分支 return → delete 走不到(C++里非常关键)
(1)代码示例
void g() { int* p = new int[100]; if (/*error*/) return; // 泄漏 // or throw ...; // 泄漏 delete[] p; }(2)“多分支 return”为什么必泄漏
因为delete 写在函数末尾,但函数的控制流可能根本到不了末尾。
你把它想成“路口很多”:
正常路径走到最后能 delete
但只要有一个分支在 delete 前 return/exit,资源就丢了
(3)例子(最典型的业务写法):
int g2() { int* p = new int[100]; if (!init()) return -1; // 泄漏 if (!check()) return -2; // 泄漏 if (!run()) return -3; // 泄漏 delete[] p; return 0; }(4)“异常 throw”为什么更危险
因为异常发生时,函数会立刻“跳出”到上层 catch,中间的代码不再执行。
void g3() { int* p = new int[100]; doSomething(); // 这里如果 throw delete[] p; // 永远走不到 }一旦doSomething()里throw
假设:
void doSomething() { throw std::runtime_error("error"); }那么执行流程会变成:
new int[100] ✅ 已执行 doSomething() ❌ 抛异常 delete[] p ❌ 不执行(5)throw 发生时,C++ 到底做了什么?
1️⃣ throw ≠ return
return:返回到调用者,函数内后面的代码还能写、能控制
throw:立即中断当前函数执行
一旦throw:
当前函数立刻停止执行
控制权直接跳到最近的
catch当前函数里剩余代码全部被跳过
所以:
delete[] p; // 永远走不到2️⃣ 异常展开(stack unwinding)只做一件事:“只会自动析构栈对象”
这就是 RAII 的根:想要异常安全,就把资源交给一个栈对象管理。
C++ 在异常展开(stack unwinding)/ 异常传播过程中时会:
- 自动调用“已经构造完成的栈对象”的析构函数
- 不会帮你 delete 任何
new出来的东西(除非它被某个栈对象管理)
⚠️ 但注意:
只析构“栈对象”
不会自动 delete 任何你 new 出来的堆内存
你的代码里:
int* p = new int[100];
p是栈变量 → 会销毁但它指向的堆内存没人管 → 泄漏
(6)为什么说 throw 比 return 更危险?
1️⃣ return:你还能“看得见”
if (error) return;你写代码时还能意识到:
“哦,我 return 前是不是该 delete?”
2️⃣ throw:可能来自你根本没意识到的地方
doSomething();你不知道:
它内部有没有
throw它调用的函数有没有
throwSTL / 第三方库会不会
throw
👉异常是“隐形出口”
(7)对比:return vs throw(一眼记住)
| 情况 | 后续代码 | 是否自动释放 new 的内存 |
|---|---|---|
| 正常执行 | 会执行 | 取决于你是否 delete |
return | 不执行 | ❌ 不会 |
throw | 不执行 | ❌ 不会 |
| throw + RAII | 不执行 | ✅ 会(析构触发) |
(8)正确写法:用 RAII 一把解决(重点)
1️⃣ 错误写法
void g3() { int* p = new int[100]; doSomething(); // throw -> 泄漏 delete[] p; }2️⃣ 正确写法 1:unique_ptr(最推荐)
#include <memory> void g3() { auto p = std::make_unique<int[]>(100); doSomething(); // throw 也安全 } // 离开作用域自动 delete[]3️⃣ 正确写法 2:用容器(工程里更常见)
void g3() { std::vector<int> v(100); doSomething(); // throw 也安全 }(9)总结
在 C++ 中,异常发生时函数会立刻中断执行并跳转到 catch,后续代码不会执行;异常展开只会析构栈对象,不会自动释放通过 new 分配的堆内存,因此裸指针在异常路径上极易导致内存泄漏,必须通过 RAII(如 unique_ptr、容器)保证异常安全。
1.3 用智能指针一把梭:为什么它能同时解决这两种问题?
1)用 unique_ptr 改写 f:再也不用手写 delete
#include <memory> void f() { auto p = std::make_unique<int>(10); // 函数结束自动释放 }2)用 unique_ptr 改写 g:return/throw 都不怕
#include <memory> void g(bool error) { auto p = std::make_unique<int[]>(100); if (error) return; // ✅ 不泄漏,return 前会析构 p // throw 也一样:抛异常时会析构 p }3)解释
p是栈对象,离开作用域必析构;析构里释放堆资源 → 所以无论 return 还是 throw 都安全。
1.4 易踩雷相关点
1)new[]必须delete[]
int* p = new int[100]; delete[] p; // ✅如果误写成delete p;是未定义行为(轻则泄漏,重则崩溃)。
2)多出口函数,手动 delete 很容易写成“漏一个分支”
所以工程里基本原则是:
- 不要在业务代码里手写
new / delete成对管理资源,- 而是始终把资源交给 RAII 对象(智能指针或容器)管理。
一句话总结就是:
只要你看到
delete,就应该警惕设计是否有问题。
1.5 总结
智能指针并不是为了“少写几行 delete”,
而是为了让资源释放这件事,从“靠人记住”,
变成“由语言机制保证一定发生”。