结论先说:有,而且只有一条正路——把资源交给对象管理,也就是 RAII,具体落地就是优先用 unique_ptr,共享所有权时才用 shared_ptr,循环引用处补一个 weak_ptr。手动 new 配 delete 的写法在异常、提前返回、多人协作这三件事面前一定会漏。

刚从有垃圾回收的语言转过来写 C++ 的人,最不适应的就是每块内存都要自己记着释放。函数里 new 了一个对象,中间抛了异常,delete 那行根本执行不到,内存就漏了;或者函数有多个返回分支,某一个分支忘写 delete。这类问题不会立刻报错,往往跑上几天才表现为内存占用持续上涨。
本文用最小示例讲清 RAII 与三种智能指针的分工,给出可直接编译运行的代码、预期输出与失败分支,并列出三个最容易踩的坑。示例基于 C++11 及以后的标准库(头文件 <memory>)编写,未在本机编译执行,请在你的编译器上按实际标准版本验证。
先看结论
- 默认选 unique_ptr:独占所有权,开销几乎为零,能覆盖绝大多数场景。
- 需要共享才用 shared_ptr:引用计数会带来额外开销,也可能出现循环引用。
- 循环引用用 weak_ptr 打破:两个对象互相持有 shared_ptr 时,谁都不会被释放。
- 不要自己写 delete:把释放动作交给对象的析构函数,异常安全才有保障。
判断顺序可以简化成一句话:能用栈对象就用栈对象,必须动态分配就用 unique_ptr,实在要共享再考虑 shared_ptr。
一、最小场景:先看手动管理有多容易出错
下面是一个刻意简化的反例(局部片段,不含 main 入口),它把三件事凑在了一起:手动 new、多个返回分支、以及中途抛异常。
#include <stdexcept>
#include <cstdio>
struct Conn {
Conn() { std::printf("open\n"); }
~Conn() { std::printf("close\n"); }
};
void handle(int n) {
Conn* c = new Conn();
if (n < 0) { return; } // 提前返回,没有 delete
if (n > 100) { throw std::runtime_error("too large"); }
delete c;
}
这段代码在 n < 0 和抛出异常两条路径上都不会执行 delete,析构函数的输出也就不会出现。真实项目里不会出现这么明显的漏洞,但"某个分支漏了"是这类写法最典型的失败模式。
⚠️ 注意:手动管理真正的麻烦不在于你写错,而在于别人改你的代码时不知道哪块内存归谁管。
二、最稳的实现:用 RAII 把释放交给析构
RAII 的意思是"资源获取即初始化"——把资源放进一个局部对象,对象离开作用域时析构函数自动释放资源。标准库已经把这套封装成了智能指针:
#include <memory>
#include <stdexcept>
#include <cstdio>
struct Conn {
Conn() { std::printf("open\n"); }
~Conn() { std::printf("close\n"); }
};
void handle(int n) {
auto c = std::make_unique<Conn>(); // 独占所有权
if (n < 0) { return; } // 自动析构
if (n > 100) { throw std::runtime_error("too large"); }
} // 正常离开也会析构
预期输出是无论走哪条路径,open 之后一定有 close。这就是 RAII 的价值:释放动作不再依赖你记得写哪一行,而是由作用域保证。
三个关键约定:
- 用
std::make_unique而不是裸new,避免"刚 new 完就抛异常"的窗口; unique_ptr不可拷贝,只可移动,这正好表达了独占语义;- 需要把所有权交出去时用
std::move,交出去之后原来的指针变量变为空。
如果还想看更基础的指针与生命周期概念,可以先过一遍 C 语言教程 里关于作用域与存储期的章节。
三、替代方案与取舍:什么时候才用 shared_ptr
三种智能指针不是三选一的等价物,它们的代价差别很大:
| 指针类型 | 所有权 | 开销 | 典型场景 |
|---|---|---|---|
unique_ptr |
独占 | 几乎为零 | 工厂函数返回、类成员持有资源 |
shared_ptr |
共享计数 | 控制块 + 原子计数 | 多处共同持有同一对象 |
weak_ptr |
不持有 | 需 lock 检查 | 打破循环引用、缓存与观察者 |
共享所有权看起来方便,代价是两件事:一是引用计数本身有原子操作开销,二是它引入了"谁最后释放"的不确定性,排查内存泄漏时会更难定位。所以默认应当优先 unique_ptr,只在确实存在多处共同持有同一对象的语义时才换成 shared_ptr。
#include <memory>
#include <cstdio>
struct Node {
std::weak_ptr<Node> parent; // 反向引用用 weak_ptr
~Node() { std::printf("node gone\n"); }
};
int main() {
auto child = std::make_shared<Node>();
auto root = std::make_shared<Node>();
child->parent = root; // 不增加引用计数
std::printf("use_count=%ld\n", root.use_count());
return 0;
}
预期输出 use_count=1,说明反向引用没有把计数抬上去。如果这里写成 shared_ptr,计数会变成 2,两个对象互相持有就谁都释放不掉。
四、反例、边界和常见失败:这 3 个坑最常见
坑一:把同一个裸指针交给多个智能指针
Node* raw = new Node();
std::shared_ptr<Node> a(raw);
std::shared_ptr<Node> b(raw); // 两个独立的控制块
两个 shared_ptr 各自维护一份计数,最终会对同一块内存释放两次,表现为程序退出时崩溃或堆损坏。正确做法是只从 make_shared 或已有的 shared_ptr 拷贝构造,绝不用同一个裸指针初始化两次。
坑二:循环引用导致内存泄漏
父子节点、观察者与被观察者这类双向关系,如果两边都用 shared_ptr,引用计数永远降不到 0,对象不会析构。判断方法很直接:在析构函数里打一行日志,程序结束时没看到输出,基本就是循环引用。处理办法是把其中一侧改成 weak_ptr。
坑三:数组与自定义删除器用错
shared_ptr<T> 默认调用 delete 而不是 delete[],用它持有数组会触发未定义行为。数组场景要么用 shared_ptr<T[]>(C++17 起支持),要么改用 std::vector,后者在绝大多数情况下都是更好的选择。

写 C++ 时偶尔要接图像处理这类第三方库,接口记不清可以翻 OpenCV 速查手册 对照。

总结
C++ 内存管理省事的办法只有一套:用 RAII 把释放动作交给析构函数,优先 unique_ptr,共享时才用 shared_ptr,双向关系用 weak_ptr 打破循环。手动 new 与 delete 的组合在任何有异常或多返回分支的代码里都会出问题,不是"小心点就能写对"的事。
要点带走:
- 默认 unique_ptr,它最便宜也最不容易出错;
- 同一块内存绝不能交给两个独立的 shared_ptr;
- 双向关系里一侧必须是 weak_ptr,否则必然泄漏。
常见问题
Q:有了智能指针就完全不会内存泄漏了吗?
A:不会自动免疫。循环引用、把资源存进全局容器忘记清理、以及第三方库内部自己分配的内存,智能指针都管不到,仍需按场景排查。
Q:unique_ptr 和 shared_ptr 该怎么选?
A:先默认 unique_ptr。只有当你能说清"确实有多个地方要同时持有它,且生命周期不确定谁最后结束"时,才升级到 shared_ptr。
Q:为什么推荐 make_shared 而不是 new?
A:make_shared 一次性分配对象与控制块,既减少一次内存分配,也避免了"对象已构造但 shared_ptr 尚未接管时抛异常"导致泄漏的窗口。
Q:weak_ptr 用起来要注意什么?
A:weak_ptr 不保证对象还存在,使用前必须 lock 成 shared_ptr 并检查是否为空,直接解引用会出问题。
延伸学习
- 想理解自动回收与手动管理的差别,可以读 Java虚拟机内存管理 这篇笔记;
- C++ 入门阶段常卡在哪,C++ 编程难点 里有对应的拆解;
- 想系统补齐语法与标准库,可以跟一遍 C++ 教程。

TRAE-AI编程



